call main
call和ret是重要的控制转移类指令,本文将详细分析这两个指令的执行过程,从而理清C语言在调用函数过程中堆栈和寄存器的变化。
call和ret的原理
函数调用命令,使用方式为:
1 | call addr |
等价于
1 | pushl %eip # 先将当前eip保存 |
所以每次调用call,会导致esp减小,因为要将返回地址进行保存。x86架构规定栈向下生长,一个使用call指令的例子如下:
Example instruction | What it does |
---|---|
call 0x12345 | pushl %eip movl $0x12345, %eip |
ret | popl %eip |
ret
函数返回命令,使用方式为:
1 | ret |
该指令等价于
1 | popl %eip |
调用函数的过程
下面我们以实际的调用函数的过程为例,对call和ret调用过程中堆栈和寄存器的变化进行研究。我们首先考虑无参函数,进一步扩展至有参函数。
调用无参函数的过程
考虑一个最简单的函数调用过程如下:
1 | // in main.c |
使用下述命令对main.c
进行编译:
1 | gcc -o build main.c -g |
输入gdb
进入调试界面,然后输入下面的命令载入符号表:
1 | (gdb) file build |
载入后,启动tui
调试界面,然后开启汇编和寄存器窗口:
1 | (gdb) tui enable |
根据汇编代码,我们可以在程序的第一条指令处设置断点。
1 | (gdb) starti |
下面我们将根据调试过程,将函数调用过程分为五个阶段,对每个阶段中栈及关键寄存器的变化进行介绍。
函数调用之前:原始栈
在调用函数之前,我们先观察调用函数的堆栈情况,根据调试结果,堆栈的结构如下:
此时,eip
恰好位于call function1
之前。
使用call命令:保存eip
调用call
命令后,该命令会首先对eip
进行保存,将其push
到栈内,然后将新命令的地址保存至eip
中。经过该操作后,栈的结构如下:
执行函数:建立新栈
在调用call
命令后,我们进入被调用函数中,开始执行被调用函数。在被调用函数中,第一个工作就是创建新栈,命令如下:
1 | push %ebp |
将旧的栈基址保存,然后将新栈基址设置为当前esp
,经过该操作后,新的栈生成:
即将从函数返回:销毁新栈
在函数调用过程中,栈也会随着局部变量的添加而增长,这里我们不关心栈中局部变量的创建和销毁过程,只关注被调用函数返回的前一时刻,我们需要对被调用函数的栈进行销毁,命令如下:
1 | pop %ebp |
现在,esp
寄存器指向了保存旧的eip
的位置,被调用函数的堆栈已经销毁,程序只需要将eip
弹出,即可恢复调用程序的堆栈,同时从调用函数中中断的位置继续执行。
使用ret命令:恢复eip
最后,被调用函数执行ret
指令,将eip
从堆栈中恢复,实际就是pop %eip
,在该操作之后,堆栈的结构如下:
在执行完成上面的命令之后,两个堆栈之间用于保存eip
的这一小段空间也被销毁,现在程序状态已经回到了调用函数之前,将会从被中断的地方继续执行。